05_BDV/07 -- Testing y evaluación de rendimiento.md

07 -- Testing y evaluación de rendimiento

El uso de la arquitectura hexagonal para este módulo hace los tests posibles sin pelearse con la infraestructura. Cada decisión del capítulo anterior — puertos como interfaces, inyección por constructor, side effects detrás de adaptadores — muestra claros beneficios a la hora de efectuar el testing: permite escribir tests rápidos, deterministas y enfocados, incluso cuando el sistema real depende de un modelo externo (Gemini) cuya salida es por naturaleza no determinista.

Este capítulo expone primero la base teórica del testing en DDD — apoyada en el material de los cursos seguidos durante el TFG — y la confronta después con la peculiaridad del dominio: ¿cómo se testea algo cuyo resultado cambia entre invocaciones idénticas?

La consecuencia práctica

Una pista es que cuando un Application Service depende de EmbeddingGenerator (puerto) en lugar de GeminiEmbeddingClient (adaptador concreto), su test ni siquiera necesita saber que existe Gemini. El puerto se sustituye por algo que devuelve un vector fijo y la lógica del caso de uso se verifica en aislamiento.


El test es espejo del código

Una propiedad conveniente con la que los tests unitarios son reforzados en DDD es la siguiente: el árbol de tests es un espejo del árbol de producción. Por cada fichero .java bajo src/main/java que requiere prueba existe un fichero homónimo bajo src/test/java, sufijado con Test, en exactamente el mismo paquete:

src/main/java/.../embeddings/                 src/test/java/.../embeddings/
├── useCase/                                  ├── useCase/
│   ├── user/                                 │   ├── user/
│   │   └── UserEmbeddingGenerator.java       │   │   └── UserEmbeddingGeneratorTest.java
│   ├── group/                                │   ├── group/
│   │   └── GroupEmbeddingGenerator.java      │   │   └── GroupEmbeddingGeneratorTest.java
│   └── variable/                             │   └── variable/
│       └── VariableEmbeddingGenerator.java   │       └── VariableEmbeddingGeneratorTest.java
└── service/                                  ├── service/
    └── EmbeddingService.java                 │   └── EmbeddingServiceTest.java
                                              └── fake/
                                                  └── FakeEmbeddingGenerator.java

Esta correspondencia es una de las fuerzas del enfoque: localizar el test de cualquier clase no requiere búsqueda — basta con saltar al mismo paquete bajo src/test/java y abrir el fichero del mismo nombre con sufijo Test. Maven Surefire lo aprovecha también: descubre automáticamente todo **/*Test.java y lo ejecuta sin configuración adicional.

Bajo src/test/java aparecen además paquetes que no existen en producción — como fake/, donde vive el FakeEmbeddingGenerator que se verá enseguida — destinados a utilidades transversales de la propia suite.


Los embeddings no son deterministas

Un test unitario tradicional asume una propiedad fundamental: dadas las mismas entradas, el código produce las mismas salidas. Esa propiedad se rompe en cuanto el caso de uso depende de un modelo de lenguaje externo.

Llamar dos veces a gemini-embedding-001 con exactamente el mismo texto puede devolver vectores ligeramente distintos. Como los LLMs son modelos no deterministas, dos invocaciones idénticas generan resultados semánticamente casi equivalentes pero vectorialmente distintos. Esto invalida el patrón clásico de aserción por igualdad:

// ❌ Esto no funciona — el valor exacto varía entre ejecuciones
final float[] expected = { 0.124f, -0.087f, /* ... 766 valores más */ };
assertThat(result.vector()).isEqualTo(expected);

¿Qué se puede afirmar sobre un vector de embedding sin caer en falsos negativos?

// ✅ Propiedades verificables sin atarse a valores concretos
assertThat(result.vector()).hasSize(768);          // Dimensiones del modelo
assertThat(result.vector()).isNotEmpty();
assertThat(response.statusCode()).isEqualTo(200);  // El cliente HTTP funcionó

Lo que un test sí puede garantizar sobre la generación de embeddings:

  • El endpoint de Gemini responde con 200 OK.
  • El vector tiene la dimensionalidad correcta (768 para gemini-embedding-001).
  • El repositorio recibe un save() con un vector no nulo.

Lo que no puede garantizar: el contenido exacto del vector. Y, en rigor, no debe intentarlo: un test que comprueba valores numéricos del modelo está testeando a Google, no al código del TFG.

La frontera del test

El test del caso de uso no es responsable de validar el modelo. Su responsabilidad es verificar que el caso de uso orquesta correctamente sus colaboradores: pide texto al builder, pide vector al generador, persiste lo recibido. El comportamiento del modelo es un contrato externo y se prueba (si acaso) en pruebas de integración con tolerancia o en evaluaciones offline de calidad semántica.

Para resolverlo, pese a que el adaptador real sea un cliente HTTP contra Gemini, en los tests unitarios lo sustituimos por una implementación alternativa escrita a mano en src/test/java:

// embeddings-app/src/test/java/.../fake/FakeEmbeddingGenerator.java

public class FakeEmbeddingGenerator implements EmbeddingGenerator {

    public static final int DIMENSIONS = 768;
    public static final float[] FIXED_VECTOR = new float[DIMENSIONS];

    @Override
    public float[] generate(final String text, final EmbeddingTaskType taskType) {
        return FIXED_VECTOR;
    }
}

Este es un Fake (siguiendo la taxonomía de Meszaros): no verifica expectaciones como un mock, simplemente ofrece un comportamiento predecible. Devuelve el mismo vector — todo ceros, exactamente 768 dimensiones — para cualquier entrada. Con esto, el test recupera la propiedad que se había perdido: dadas las mismas entradas, salida idéntica.

Mock vs Stub vs Fake
  • Stub: devuelve datos preconfigurados. No verifica nada.
  • Mock: verifica expectaciones (qué métodos se llamaron, con qué argumentos, cuántas veces).
  • Fake: implementación funcional pero simplificada. FakeEmbeddingGenerator es un fake porque implementa la interfaz de forma real, sólo que con un vector trivial.

El módulo combina los tres: Mockito genera mocks para repositorios, FakeEmbeddingGenerator.FIXED_VECTOR actúa como stub de retorno, y la propia clase es un fake reusable.


Anatomía de un test de capa de aplicación: UserEmbeddingGeneratorTest

El patrón canónico del testing en aplicación según DDD es: mockear los puertos, ejecutar el caso de uso, verificar que los puertos recibieron las llamadas esperadas. El test de UserEmbeddingGenerator aplica esa receta sin desviaciones, y por eso sirve como ejemplo prototípico del enfoque que se sigue en el resto del módulo.

@ExtendWith(MockitoExtension.class)
class UserEmbeddingGeneratorTest {

    @Mock private UserEmbeddingRepository repository;
    @Mock private EmbeddingGenerator embeddingGenerator;
    private final UserEmbeddingTextBuilder textBuilder = new UserEmbeddingTextBuilder();

    @Test
    void generatesAndSavesWhenStoredTextIsAbsent() {
        final UserContext user = new UserContext(1L, "jlopez", "Responsable de planta");
        final String expectedText = textBuilder.build(user);

        when(repository.find(1L)).thenReturn(user);
        when(repository.searchStoredText(1L)).thenReturn(Optional.empty());
        when(embeddingGenerator.generate(expectedText, RETRIEVAL_DOCUMENT))
            .thenReturn(FakeEmbeddingGenerator.FIXED_VECTOR);

        new UserEmbeddingGenerator(repository, embeddingGenerator, textBuilder).generate(1L);

        verify(embeddingGenerator).generate(expectedText, RETRIEVAL_DOCUMENT);
        verify(repository).save(1L, expectedText, FakeEmbeddingGenerator.FIXED_VECTOR);
    }
}

Tal como hemos explicado previamente, por cada clase de producción que requiere prueba existe en src/test/java una clase equivalente sufijada con Test. UserEmbeddingGenerator se valida en UserEmbeddingGeneratorTest, OnUserCreator en OnUserCreatorTest, VariableEmbeddingTextBuilder en VariableEmbeddingTextBuilderTest, etcétera. Esta correspondencia uno-a-uno hace que la suite refleje la topología del módulo y permite localizar el test de cualquier clase sin más que abrir el fichero del mismo nombre bajo src/test/java.

El test anterior le corresponde a UserEmbeddingGenerator, una clase de la capa de aplicación. Tiene tres dependencias. Las dos primeras son puertos: UserEmbeddingRepository, que termina tocando PostgreSQL, y EmbeddingGenerator, que termina llamando a Gemini. Para que el test sea rápido y se mantenga en el plano de la aplicación, ambas tienen que ses reemplazados por mocks de Mockito.

La tercera, UserEmbeddingTextBuilder, es distinta: son dominio puro, una función sin estado ni efectos secundarios cuya salida queda determinada al 100% por su entrada. Mockearlo sería sustituir lógica que en realidad queremos ejecutar, y el test perdería capacidad de detectar regresiones en la construcción del prompt que se envía al modelo. La regla que se desprende del curso, y que el módulo respeta sin excepciones, es ésta: sólo se mockean puertos, nunca código de dominio.

La estructura del test reproduce el triple acto clásico — arrange, act, assert — pero con vocabulario hexagonal: primero se programan los stubs de los puertos con when(...).thenReturn(...), luego se invoca el caso de uso con un identificador real, y finalmente se verifican las interacciones con verify(...). La aserción no recae sobre el valor devuelto (el método es void) sino sobre las llamadas que el SUT debió emitir contra sus colaboradores. Es la diferencia esencial entre el testing basado en estado y el basado en interacción, y aquí se opta por el segundo precisamente porque el caso de uso es orquestación pura.


Tests de dominio: cuando no hace falta ni Spring ni mocks

La capa de dominio es probablemente la mas sencilla de testear. Las funciones que construyen el texto para el embedding no dependen de ningún puerto, llamando dos veces con los mismos parametros siempre devolveral el mismo resultado. Por ende, el testing de esta capa son JUnit clásicos:

class VariableEmbeddingTextBuilderTest {

    private final VariableEmbeddingTextBuilder builder = new VariableEmbeddingTextBuilder();

    @Test
    void buildContainsNameAndDescription() {
        final VariableContext ctx = new VariableContext(
            1L, "Sensor Temperatura", null,
            "Temperatura entrada compresor", "°C", List.of()
        );

        final String result = builder.build(ctx);

        assertThat(result)
            .contains("# Sensor Temperatura")
            .contains("Temperatura entrada compresor");
    }

    @Test
    void buildOmitsUnitWhenNull() {
        final VariableContext ctx = new VariableContext(1L, "Sensor", null, "desc", null, List.of());
        assertThat(builder.build(ctx)).doesNotContain("Unit of measurement");
    }
}
Tests sin marca y sin contenedor

Estos tests no necesitan brand=<marca>, no arrancan Spring, no abren conexiones. Son los más rápidos del módulo (milisegundos) y los que más densamente cubren la lógica de construcción de prompts. Su existencia confirma una buena separación: la lógica que decide qué texto enviar a Gemini vive en el dominio, no está mezclada con infraestructura.


Tests de integración: @SpringBootTest + pgvector real

En el caso de la capa de repositorios de un test unitario no puede validar el mapeo a vector(768) de pgvector. Sólo tocando la base de datos real se descubre si la columna está bien declarada, si el operador <=> funciona o si la unicidad (type_id, entity_id) rechaza duplicados.

@SpringBootTest
@Transactional
class EmbeddingSearchRepositoryImplTest {

    @Autowired private EmbeddingSearchRepository searchRepository;
    @Autowired private GroupEmbeddingRepository groupRepository;

    @Test
    void searchScoresAreBetweenZeroAndOne() {
        groupRepository.save(99981L, "grupo operaciones", randomVector());
        groupRepository.save(99982L, "grupo produccion", randomVector());

        final List<EmbeddingSearchResult> results = searchRepository.searchSimilar(
            EmbeddingSearchCriteria.allEntities("temperatura", 10),
            randomVector()
        );

        assertThat(results).allMatch(r -> r.score() >= 0.0 && r.score() <= 1.0);
    }

    // randomVector(): helper privado que genera un float[768] con Random.nextFloat()
}

Aquí ya no se mockea nada. Spring autoinyecta la implementación real del repositorio y el test verifica propiedades de extremo a extremo. La principal, la que ilustra el snippet, es que el score cae siempre en el intervalo [0, 1], lo que confirma que la conversión 1 - distance aplicada sobre el operador <=> está correcta. Hay otros dos tests en el mismo fichero, omitidos aquí por brevedad: uno recorre la lista de resultados y comprueba que llega ordenada de mayor a menor, validando el ORDER BY del SQL; el otro filtra por EntityType y comprueba que la vista unificada respeta el WHERE entity_type = ?.

El truco del `@Transactional`

Igual que el curso advierte sobre el clearUnitOfWork de Doctrine/Hibernate, aquí @Transactional cumple la función dual de aislar tests entre sí (rollback automático) y forzar el flush al usar el repositorio real. Sin él, los save del seed podrían contaminar tests siguientes.


Conclusión: cada capa, su forma de testear

Visto el recorrido por las tres capas, el patrón se reduce a una idea sencilla: cada capa se testea como mejor encaja con su naturaleza, no con una receta única.

La capa de dominio se prueba con tests JUnit clásicos, sin Spring y sin mocks, porque vive aislada de la infraestructura. Sus clases no dependen de nada externo — funciones puras, value objects, entidades — y eso permite ejecutar la suite en milisegundos. El VariableEmbeddingTextBuilderTest es un ejemplo: mete un VariableContext por un lado y comprueba la cadena que sale por el otro.

La capa de aplicación es donde cae la mayor parte de los tests del módulo. Aquí lo que se prueba es un caso de uso que orquesta puertos hacia fuera del hexágono, así que el patrón es el del curso: Mockito para los puertos, instancia real para el dominio puro, y verify(...) para comprobar las interacciones. El UserEmbeddingGeneratorTest es el ejemplo prototípico: prueba el flujo entero (qué texto se construye, qué vector se pide, qué se persiste) sin tocar PostgreSQL ni Gemini.

La capa de persistencia es la única que no puede mockearse, porque lo que se quiere validar es justo el contrato con la base de datos: que el mapeo a vector(768) funciona, que el operador <=> devuelve lo esperado, que la unicidad rechaza duplicados. Aquí entran @SpringBootTest y @Transactional.